Skip to content

ast: record the parentheses on a call site (Call.Paren) - #43

Merged
tannevaled merged 1 commit into
mainfrom
wave-h-call-paren
Sep 28, 2026
Merged

tannevaled merged 1 commit into
mainfrom
wave-h-call-paren

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Closes #41.

The gap

foo and foo() built identical ast.Calls, so nothing downstream could tell a VCALL from an FCALL, and ast.Call carries no position from which to re-read the source. Reproduction from the issue, against ruby 4.0.5:

nope_xyz     # NameError:     undefined local variable or method 'nope_xyz' for main
nope_xyz()   # NoMethodError: undefined method 'nope_xyz' for main

The grammar says where the information is

rule / function line in parse.y-ruby_4_0 what it does
fcall : operation 3572-3577 NEW_FCALL($1, 0, &@$)
method_call : fcall paren_args 5242-5248 attaches the parenthesised list — this is the FCALL
gettable, case ID_LOCAL 13086 return NEW_VCALL(id, loc) — the VCALL
primary : tFID 4370-4374 NEW_FCALL for foo! / foo? — no parentheses, still an FCALL
operation : ident_or_const | tFID 6696-6698 why tFID reaches fcall at all
primary : fcall brace_block 4458-4463 a literal block makes an FCALL too
primary_value '[' opt_call_args rbracket 5292-5297 an index is a NEW_CALL, with brackets, not parens

Paren is set wherever the parser consumes a written ( … ) argument list: the bare-identifier call, a capitalised call (Integer("42")), a ./&. call, a :: call, and the .() shorthand.

The bit takes both values

Measured by the tests added here — Paren is neither always true nor always false, and each pair that must differ does:

spelling Name Paren args
foo foo false 0
foo() foo true 0
foo(1) foo true 1
foo () foo false 1
puts 1 puts false 1
obj.foo foo false 0
obj.foo() foo true 0
obj.foo bar foo false 1
obj&.foo / obj&.foo() foo false / true 0
foo.() / foo.(1) call true 0 / 1
Foo::bar / Foo::bar(1) bar false / true 0 / 1
Integer("42") Integer true 1
a[0] [] false 1
foo! / foo? foo! / foo? false 0
x = 1; x — VarRef, not a Call —
x = 1; x() x true 0

Two of those need saying out loud:

  • foo () is Paren: false with one argument. The space means the () is a grouped expression passed as a command argument, not an argument list. MRI agrees it is an FCALL, and it stays one for a consumer, because a VCALL also requires zero arguments.
  • foo! and foo? are Paren: false and yet FCALLs in MRI (primary: tFID), confirmed against the binary: both raise NoMethodError. So the bit alone does not decide VCALL, and the doc comment says so rather than letting a consumer guess. The rule is Recv == nil && !Paren && len(Args) == 0 && Block == nil on a plain identifier name, and TestCallParenVCallRule exercises exactly that rule over the whole edge set against MRI's answers.

Negative control: with every Paren forced to false, 12 assertions in the new tests fail. The tests measure the change.

One place the oracle and the binary disagree

The {x:} value-omitted shorthand. assoc : tLABEL (6669-6675) calls gettable, so the implied value is a VCALL, and our barewordValue builds a Call with Paren: false accordingly. The installed ruby 4.0.5 raises NoMethodError there, which would say FCALL — but that binary is +PRISM and runs the Prism parser by default; ruby --parser=parse.y raises NameError, agreeing with the oracle. The oracle wins, and this PR follows it. Flagging it because a consumer measuring against a stock ruby will see the Prism answer.

Existing consumers are unaffected

  • No existing test was changed. git diff --name-only origin/main is ast/ast.go and parser.go only; the one new file is call_paren_test.go.
  • Full suite green before and after (parser, ast, lexer, token), go vet ./... clean, gofmt -l clean on both changed files.
  • gofmt -l . does report paren_singleton_def_test.go — pre-existing on main (verified against a pristine origin/main copy) and untouched here.

The zero value is false, so anyone who does not read the field sees nothing.

Why this is a separate PR from #40

No shared code: different structs, disjoint parser sites, no common helper. Same idea, same precedent (RangeLit.Exclusive), but nothing is reused by merging them, and split they review and merge independently.

What the consumer will need (separately)

Not in this PR — go-embedded-ruby/ruby needs a released parser version first, and a sibling wave is working there. When it picks this up: the existing FlagSendVCall should be set from Recv == nil && !Paren && len(Args) == 0 && Block == nil && Name a plain identifier (no trailing !, ? or =), which is what TestCallParenVCallRule here encodes. That flag then selects NameError over NoMethodError, covering language/send_spec.rb:235.

🤖 Generated with Claude Code

`foo` and `foo()` built identical `ast.Call`s, so nothing downstream could
tell a VCALL from an FCALL. `ast.Call` carries no position either, so a
consumer could not re-read the source to recover the parentheses. The
difference is observable in MRI:

  nope_xyz     # NameError:     undefined local variable or method 'nope_xyz'
  nope_xyz()   # NoMethodError: undefined method 'nope_xyz'

MRI keeps them apart by building different nodes from different
productions: `fcall: operation` feeding `method_call: fcall paren_args`
(parse.y-ruby_4_0:3572-3577, 5242-5248) makes an FCALL, while a bare name
reaching `gettable` makes a VCALL (parse.y-ruby_4_0:13086). This records
the same distinction as one bit of written form, set wherever the parser
consumes a written `(` … `)` argument list: the bare-identifier call, a
capitalised call, a `.` or `&.` call, a `::` call, and the `.()`
shorthand.

Paren is written form and nothing more. It is true for an empty list
(`foo()`) and false for every unparenthesised call whatever else is
present, including `a[0]` — brackets are not parentheses (MRI:
`primary_value '[' opt_call_args rbracket`, parse.y-ruby_4_0:5292-5297).

Deciding VCALL takes more than this bit, and the doc comment says so: MRI's
`operation` admits tFID, so `foo!` and `foo?` are FCALLs with no
parentheses (parse.y-ruby_4_0:4370-4374, 6696-6698), and a literal block
makes an FCALL too (`primary: fcall brace_block`,
parse.y-ruby_4_0:4458-4463). The rule a consumer wants is
`Recv == nil && !Paren && len(Args) == 0 && Block == nil` on a plain
identifier name; a test exercises exactly that rule over the edge set.

The zero value is false, so no existing consumer changes. No existing test
was modified; the full suite passes unchanged.

Closes #41

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 8490e30 into main Sep 28, 2026
11 checks passed
@tannevaled
tannevaled deleted the wave-h-call-paren branch September 28, 2026 20:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ast: Call cannot distinguish foo from foo() (VCALL vs FCALL)

1 participant